⚠️ 学习声明:本文档基于 Claude Code 2.1.88 源码分析整理,仅供个人学习研究使用,不做任何商业用途。
VCR(Video Cassette Recorder)是 CC 的 API 调用录制/回放系统——让测试无需真实调用 Anthropic API,同时保证与真实 API 行为完全一致。
一、核心理念
问题
| 测试场景 |
挑战 |
| 单元测试跑 Agentic Loop |
需要调用 Claude API,慢且昂贵 |
| CI/CD 环境 |
没有 API Key 或配额,无法调用 |
| 测试一致性 |
API 返回随机,测试结果不可复现 |
| 开发迭代速度 |
每次测试等待 API 响应 |
解决方案
VCR 模式:首次运行时”录制”真实 API 响应存为 fixture 文件,后续复用”回放”录制内容——完全跳过网络调用。
VCR_RECORD=1(录制模式) 正常模式(回放) ↓ ↓ 调用真实 Anthropic API 读取 fixture 文件 ↓ ↓ 保存 fixture JSON 返回缓存响应 ↓ ↓ 返回响应 完全跳过网络
|
二、三种 VCR 函数
2.1 withVCR — 批量消息录制/回放
withVCR( messages: Message[], f: () => Promise<(AssistantMessage | StreamEvent | SystemAPIErrorMessage)[]> ): Promise<(AssistantMessage | StreamEvent | SystemAPIErrorMessage)[]>
|
用途:Agentic Loop 中非流式的完整 API 响应录制。
Fixture 命名策略:
const filename = `fixtures/${messages.map(m => sha1(dehydrateValue(m.content)).slice(0, 6) ).join('-')}.json`
|
2.2 withStreamingVCR — 流式响应录制/回放
async function* withStreamingVCR( messages: Message[], f: () => AsyncGenerator<StreamEvent | AssistantMessage | SystemAPIErrorMessage> ): AsyncGenerator<StreamEvent | AssistantMessage | SystemAPIErrorMessage>
|
用途:流式 API 响应的录制——先消费完整个 generator 缓存,再按顺序回放。
const buffer = [] for await (const message of f()) { buffer.push(message) }
|
2.3 withTokenCountVCR — Token 计数录制/回放
withTokenCountVCR( messages: unknown[], tools: unknown[], f: () => Promise<number | null> ): Promise<number | null>
|
用途:/count_tokens API 调用的录制。
额外脱敏处理:
const dehydrated = dehydrateValue(jsonStringify({messages, tools})) .replaceAll(cwdSlug, '[CWD_SLUG]') .replace(/UUID-pattern/g, '[UUID]') .replace(/timestamp-pattern/g, '[TIMESTAMP]')
|
这样不同机器、不同时间跑出的 hash 相同,fixture 可以在团队间共享。
三、VCR 启动条件
function shouldUseVCR(): boolean { if (process.env.NODE_ENV === 'test') return true if (process.env.USER_TYPE === 'ant' && isEnvTruthy(process.env.FORCE_VCR)) return true return false }
|
四、脱敏(Dehydrate)机制
问题
Fixture 文件的 hash 必须跨机器、跨时间保持一致,但消息中包含大量环境相关信息:
- 工作目录绝对路径(
/home/zhanglin/project)
- 配置目录(
~/.claude)
- UUID(每次生成不同)
- 时间戳
- 动态数字(文件数量、执行时长、成本)
dehydrateValue 脱敏规则
function dehydrateValue(s: string): string { return s .replace(/num_files="\d+"/g, 'num_files="[NUM]"') .replace(/duration_ms="\d+"/g, 'duration_ms="[DURATION]"') .replace(/cost_usd="\d+"/g, 'cost_usd="[COST]"') .replaceAll(configHome, '[CONFIG_HOME]') .replaceAll(cwd, '[CWD]') .replace(/Available commands:.+/, 'Available commands: [COMMANDS]') }
|
Windows 兼容性:
不替换所有斜杠:原注释解释了为什么:
// 不替换所有前斜杠为 path.sep,会损坏 XML-like 标签 // 如 </system-reminder> 会变成 <\system-reminder>
|
hydrateValue 还原规则
function hydrateValue(s: string): string { return s .replaceAll('[NUM]', '1') .replaceAll('[DURATION]', '100') .replaceAll('[CONFIG_HOME]', getClaudeConfigHomeDir()) .replaceAll('[CWD]', getCwd()) }
|
五、UUID 唯一性保障
问题
sessionStorage.ts 使用 UUID 对消息去重。VCR 回放时如果多次 withVCR 调用返回相同 UUID,不同请求的响应会被识别为”重复消息”丢弃。
解决方案
function mapAssistantMessage( message: AssistantMessage, f: (s: unknown) => unknown, index: number, uuid?: UUID ): AssistantMessage { return { uuid: uuid ?? (`UUID-${index}` as unknown as UUID), requestId: 'REQUEST_ID', ... } }
|
六、CI 中的 Fixture 强制检查
if ((env.isCI || process.env.CI) && !isEnvTruthy(process.env.VCR_RECORD)) { throw new Error( `Fixture missing: ${filename}. Re-run tests with VCR_RECORD=1, then commit the result.` ) }
|
工作流:
开发者本地:VCR_RECORD=1 pnpm test ↓ 生成新 fixture 文件 git add fixtures/*.json && git commit ↓ CI 环境:pnpm test(无 VCR_RECORD) ↓ 读取 fixture,不调用 API ↓ 测试通过
|
七、通用 Fixture 管理(withFixture)
async function withFixture<T>( input: unknown, fixtureName: string, f: () => Promise<T> ): Promise<T> { const hash = sha1(jsonStringify(input)).slice(0, 12) const filename = `fixtures/${fixtureName}-${hash}.json` try { return jsonParse(await readFile(filename)) } catch (e) { if (e.code !== 'ENOENT') throw e } if (env.isCI && !VCR_RECORD) throw new Error(...) const result = await f() await writeFile(filename, jsonStringify(result, null, 2)) return result }
|
八、成本追踪集成
function addCachedCostToTotalSessionCost( message: AssistantMessage | StreamEvent ): void { const costUSD = calculateUSDCost(model, usage) addToTotalSessionCost(costUSD, usage, model) }
|
即使是 VCR 回放,fixture 中记录的 model/usage 信息也会被加入总成本,保持 cost tracking 测试的完整性。
九、关键设计决策
| 决策 |
原因 |
| SHA1 内容寻址 |
相同输入永远映射到相同 fixture,无需手动管理 |
| dehydrate/hydrate |
让 fixture 跨机器/时间复用,团队共享 |
| 流式 VCR 先缓冲 |
Generator 无法直接序列化,先收集再存储 |
| randomUUID 在回放时 |
防止 sessionStorage 的 UUID 去重丢消息 |
| CI 强制 fixture 存在 |
防止 CI 静默跳过测试(fixture 缺失应是显式失败) |
| 成本继续追踪 |
保持 cost tracking 系统在测试中的完整可测性 |
十、面试要点
Q:VCR 和 Mock 有什么本质区别?
Mock 替换接口行为(自定义返回),可能与真实 API 出现漂移。VCR 录制真实 API 响应,回放时完全重现真实行为。大型团队曾因 mock 与 prod 漂移,测试全绿但上线崩溃。CC 选择 VCR 是因为”AI 输出有语义”,mock 一个假的 AI 回复没有测试价值。
Q:为什么 Token Count VCR 需要额外的 UUID/时间戳标准化?
每次测试运行,消息里带新 UUID 和当前时间戳,导致 hash 不同,fixture 每次失效。标准化后相同逻辑输入产生相同 hash,团队成员 A 录制的 fixture 可以被成员 B 在 CI 中使用。
涉及源文件